<?php
/**
 * scottlib Buckets
 * @author Aaron Collegeman aaroncollegeman.com
 * @since 0.1.3
 *
 * Copyright (C)2008 Collegeman.net, LLC.
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License along
 * with this program; if not, write to the Free Software Foundation, Inc.,
 * 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301 USA. 
 */

// ----------------------------------------------------------------------------
// Don't touch anything in this file.  That way when there's an update, you
// can just overwrite this file with the new one.  Go ahead, we warned you!
// ----------------------------------------------------------------------------

/**
 * SimpleDB does not differentiate between text and numeric values, therefore
 * all numeric values must be padded.  Padding allows for sorting of the numeric
 * data, because without it 11, 111, and 1111 will all be sorted ahead of 2
 * in ascending order.
 *
 * This padding is handled by slData automatically, and within the default range
 * of -1,000,000 to 2,146,483,647.  These maximums can be overridden in 
 * scottlib-cfg.php or by whatever framework scottlib happens to be included into.
 */
if (!defined('SIMPLEDB_MAX_NEGATIVE_VALUE'))
	define('SIMPLEDB_MAX_NEGATIVE_VALUE', -1000000);

if (!defined('SIMPLEDB_MAX_POSITIVE_VALUE')) 
	define('SIMPLEDB_MAX_POSITIVE_VALUE', 2147483647+SIMPLEDB_MAX_NEGATIVE_VALUE);

/**
 * This class is a wrapper for an arbitrary value of any type, and is a marker
 * that indicates to the slData bucket interface that the wrapped value should be
 * a replacement for any/all values stored in the attendant key slot (itself not
 * referenced by this class).
 *
 * @since 0.1.0
 * @see slData
 */ 
class slReplacement {
	
	public $value;
	
	function __construct($value) {
		$this->value = $value;
	}
	
	/**
	 * @deprecated Just reference the $value property instead.
	 */
	function getValue() {
		return $this->value;	
	}
	
}

if (!function_exists('replacement')) {
	/**
	 * @see slReplacement
	 */
	function replacement($value) {
		return new slReplacement($value);
	}
}



class slData {
	
	private $_domain = null;
	
	private $_itemName = null;
	
	private $_system = null;
	
	private static $_systems = array();
	
	private static $_engines = array();
	
	private static $_options = array();
	
	private $_data = array();
	
	function __construct($domain, $itemName = null, $system = 'default') {
		$this->_domain = $domain;
		$this->_itemName = $itemName;
		$this->_system = $system;
	}
	
	function id() {
		return $this->_itemName;	
	}
	
	function itemName() {
		return $this->_itemName;	
	}
	
	function getItemName() {
		return $this->_itemName;	
	}
	
	function update() {
		foreach($this->_data as $name => $value) {
			if (get_class($value) !== 'slReplacement')
				$this->_data[$name] = new slReplacement($value);
		}	
		$this->save();
	}
	
	private static function getEngine($system) {
		if (!isset(self::$_engines[$system])) {
			if (!isset(self::$_systems[$system])) {
				if ($system == 'default') {

					// FIXME: do sfConfig detection here... if at all.
					throw new Exception("You must configure at least one data system before using scottlib.");
						
				}
				else
					throw new Exception("You didn't configure a data system named [$system].");
			}
			
			if (self::$_systems[$system]['type'] == 'mysql')
				self::$_engines[$system] = new slMySqlEngine(self::$_systems[$system]);
			else if (self::$_systems[$system]['type'] == 'simpledb')
				self::$_engines[$system] = new slSimpleDbEngine(self::$_systems[$system]);
			else
				throw new Exception("Invalid state: unrecognized data system type, [$system].");
		}
		
		return self::$_engines[$system];
	}
	
	function getValues() {
		$values = array();
		foreach ($this->_data as $name => $value) {
			$v = $value;
			
			$v = (is_bool($v)) ? (int) $v : $v;
			
			if (strpos($name, '__') === false)
				$values[$name] = $v;	
		}
		return $values;	
	}
	
	function save() {
		$engine = slData::getEngine($this->_system);
		$this->_itemName = $engine->put($this->_domain, $this->_itemName, $this->getValues());
	}
	
	function delete() {
		
	}
	
	static function deleteDomain($domain, $system = 'default') {
		$engine = slData::getEngine($system);
		$engine->deleteDomain($domain);
	}
	
	static function createDomain($domain, $system = 'default') {
		if (preg_match('/[^a-zA-Z0-9_\-\.]/', $domain) || strlen($domain) < 3 || strlen($domain) > 255)
			throw new Exception("Domain may contain only a-z, A-Z, 0-9, '_', '-', and '.' and must be between 3 and 255 characters in length.");
		
		$engine = slData::getEngine($system);
		$engine->createDomain($domain);	
	}
	
	function __set($name, $value) {
		if ($name == 'itemName' || $name == 'id')
			$this->_itemName = $value;
		else 
			$this->_data[$name] = $value;
	}
	
	function __get($name) {
		if ($name == 'itemName' || $name == 'id')
			return $this->_itemName;
		else 
			return $this->_data[$name];
	}
	
	static function addSystem($options = array()) {
		if (!isset($options['name']))
			$options['name'] = 'default';
		
		if (!isset($options['type']) || !in_array($options['type'], array('mysql', 'simpledb')))
			throw new Exception("System type must be either 'mysql' or 'simpledb'");

		if ($options['type'] == 'mysql') {
			// FIXME: finish validating $options
		}
		
		self::$_systems[$options['name']] = $options;
	}
	
	static function getSystems() {
		return self::$_systems;	
	}
	
}

interface slEngine {
	
	function put($domain, $itemName, $values = array());
	function delete($domain, $itemName);
	function get($domain, $id, $attributes);
	function createDomain($domain);
	function deleteDomain($domain);
	function meta($domain);
	function listDomains();
	function query($query);
	function select($select);
	function queryWithAttributes($domain, $attributes);
	
}

abstract class slBaseEngine implements slEngine {
	
	protected function parseSelect($select) {
		
	}
	
	protected function parseQuery($query) {
		
	}
	
}

class slMySqlEngine extends slBaseEngine  {
	
	private $_conn;
	
	function __construct($options) {
		$this->_conn = new slMySqlConn($options['host'], $options['user'], $options['pass'], $options['database']);
	}
	
	private function buildSafeInsert($itemName, $name, $value) {
		if (is_array($value) || is_object($value)) {
			$serialized = utf8_encode('$php.serialized='.serialize($value));	
			if (strlen($serialized) > 1024)
				throw new Exception("Serialized value for property [$name] is too long: must be fewer than 1024 characters.");
			return slMySqlConn::prepare("(%s, %s, %s)", array($itemName, $name, $serialized));
		}
		else {
			$value = utf8_encode($value);
			if (strlen($value) > 1024)
				throw new Exception("Value for property [$name] is too long: must be fewer than 1024 characters.");
			return slMySqlConn::prepare("(%s, %s, ?)", array($itemName, $name, $value));
		}
	}
	
	private function generateSql($domain, $itemUniq, &$values, &$deletes, &$inserts) {
		
		$dataTable = "scottlib_".$domain."_data";
		
		foreach ($values as $name => $value) {
			
			$encname = utf8_encode($name);
			
			if (strlen($encname) > 1024)
				throw new Exception("Property name [$encname] must be fewer than 1024 characters.");
			
			if (is_array($value)) {
				if (array_all_keys_numeric($value)) {
					foreach($value as $i => $v)
						$inserts[] = $this->buildSafeInsert($itemUniq, $encname, $v);
				}
				else
					$inserts[] = $this->buildSafeInsert($itemUniq, $encname, $value);	
			}
			else if (is_object($value) && get_class($value) != 'slReplacement') {
				$inserts[] = $this->buildSafeInsert($itemUniq, $encname, $value);	
			}
			else if (get_class($value) == 'slReplacement') {
				$deletes[] = slMySqlConn::prepare("DELETE FROM # WHERE `item_uniq` = %s AND `attribute_name` = %s", array(
					$dataTable,
					$itemUniq,
					$encname
				));
				
				$replacementValue = $value->getValue();
				
				if (is_array($replacementValue) && (array_all_keys_numeric($replacementValue))) {
					foreach($replacementValue as $i => $v)
						$inserts[] = $this->buildSafeInsert($itemUniq, $encname, $v);
				}
				else
					$inserts[] = $this->buildSafeInsert($itemUniq, $encname, $replacementValue);
			}
			else {
				$inserts[] = $this->buildSafeInsert($itemUniq, $encname, $value);
			}
				
		}
		
	}
	
	function createDomain($domain) {
		
		$dataTable = "scottlib_".$domain."_data";
		$catalogTable = "scottlib_".$domain."_catalog";
			
		try {
			$this->_conn->execute("
				CREATE TABLE IF NOT EXISTS `$dataTable` (
					`item_uniq` VARCHAR(32) NOT NULL,
					`attribute_name` VARCHAR(1024) CHARACTER SET 'utf8' NOT NULL,
					`value` VARCHAR(1024) CHARACTER SET 'utf8',
					INDEX (`item_uniq`),
					INDEX (`attribute_name`)
				) type=InnoDB;
			");
		} catch (Exception $e) {
			throw new Exception("Failed to create data table for [$domain]: ".$e->getMessage());	
		}
		
		try {
			$this->_conn->execute("
				CREATE TABLE IF NOT EXISTS `$catalogTable` (
					`uniq` VARCHAR(32) NOT NULL PRIMARY KEY,
					`item_name` VARCHAR(1024) NOT NULL,
					INDEX (`item_name`)
				) type=InnoDB;
			");
		} catch (Exception $e) {
			throw new Exception("Failed to create catalog table for [$domain]: ".$e->getMessage());	
		}
		
		return array($dataTable, $catalogTable);
		
	}
	
	function deleteDomain($domain) {
		
		$dataTable = "scottlib_".$domain."_data";
		$catalogTable = "scottlib_".$domain."_catalog";
			
		try {
			$this->_conn->execute("
				DROP TABLE IF EXISTS `$dataTable`
			");
		} catch (Exception $e) {
			throw new Exception("Failed to drop data table for [$domain]", $e);	
		}
		
		try {
			$this->_conn->execute("
				DROP TABLE IF EXISTS `$catalogTable`
			");
		} catch (Exception $e) {
			throw new Exception("Failed to drop catalog table for [$domain]", $e);	
		}
		
	}
	
	
	function put($domain, $itemName, $values = array()) {
		
		if (!$itemName)
			$itemName = md5(uniqid('scottlib'.$domain, true));
		
		$itemUniq = md5($itemName);	
		list ($dataTable, $catalogTable) = self::createDomain($domain);
			
		// prepare data manipulation:
		$deletes = array();
		$inserts = array();
		$this->generateSql($domain, $itemUniq, $values, $deletes, $inserts);
		
		$this->_conn->begin();

		// first, clear out any data being replaced:
		foreach($deletes as $d)
			$this->_conn->delete($d);
		
		// next, update the data stored for the item:
		if (count($inserts)) {
			$insert = "INSERT INTO `$dataTable` (`item_uniq`,`attribute_name`,`value`) VALUES ".join(",", $inserts);
			$this->_conn->insert($insert);
		}
		
		// finally, make sure item exists in the catalog
		$this->_conn->insert(
			slMySqlConn::prepare('REPLACE INTO # (`uniq`, `item_name`) VALUES (%s, %s)', array(
				$catalogTable, 
				$itemUniq, 
				$itemName
			))
		);

		$this->_conn->commit();
		
		return $itemName;
	}
	
	function delete($domain, $itemName) {}
	function get($domain, $id, $attributes) {}
	function meta($domain) {}
	function listDomains() {}
	function query($query) {}
	function select($select) {}
	function queryWithAttributes($domain, $attributes) {}
}

class slSimpleDbEngine  {
	
	static function numberEncode($value = null, $max_negative_value = SIMPLEDB_MAX_NEGATIVE_VALUE, $max_positive_value = SIMPLEDB_MAX_POSITIVE_VALUE) {
		if ($value === null)
			return null;
			
		if (!is_numeric($value))
			throw new Exception("Property $property_or_null is not numeric: $value");
		
		if ($value < $max_negative_value)
			throw new Exception("Value $value cannot be less than $max_negative_value");
		
		if ($value > $max_positive_value)
			throw new Exception("Value $value cannot be more than $max_positive_value");
			
		// first, pad the numeric value
		$value -= $max_negative_value;
		
		// next, pad the string value
		if (!preg_match('/(\d+)(\..*)/', $value, $matches)) {
			$integer = $value;
			$decimal = '';
		}
		else {
			$integer = $matches[1];
			$decimal = $matches[2];
		}
		
		while (strlen($integer) < strlen($max_positive_value))
			$integer = '0'.$integer;
			
		return $integer.($decimal ? $decimal : '');
	}
	
	static function numberDecode($value = null, $max_negative_value = SIMPLEDB_MAX_NEGATIVE_VALUE, $max_positive_value = SIMPLEDB_MAX_POSITIVE_VALUE) {
		if ($value === null)
			return null;
		
		// remove string padding
		$value = preg_replace('/^0+/', '', $value_or_object);
		
		// next, remove value padding
		$value += $max_negative_value;

		return $value;
	}
	
	static function date($value = null) {
		
		if ($value == null)
			$timestamp = time();
		else if (($timestamp = strtotime($value)) === false)
			throw new Exception("Failed to parse date value of [$value].");

		return date('c', $timestamp);
	}
	
	
}

if (!function_exists('simpledb_encode_number')) {
	function simpledb_encode_number($value, $max_negative_value = SIMPLEDB_MAX_NEGATIVE_VALUE, $max_positive_value = SIMPLEDB_MAX_POSITIVE_VALUE) {
		return slSimpleDbEngine::numberEncode($value, $max_negative_value, $max_positive_value);
	}
}

if (!function_exists('simpledb_decode_number')) {
	function simpledb_decode_number($value, $max_negative_value = SIMPLEDB_MAX_NEGATIVE_VALUE, $max_positive_value = SIMPLEDB_MAX_POSITIVE_VALUE) {
		return slSimpleDbEngine::numberDecode($value, $max_negative_value, $max_positive_value);
	}	
}

if (!function_exists('simpledb_encode_date')) {
	function simpledb_encode_date($value = null) {
		return slSimpleDbEngine::date($value);	
	}
}


